/**
 * Copyright 2008 Mathias Doenitz, http://lis.to/
 *
 * This file is part of the lis.to java desktop client. The lis.to java desktop client is free software: you can
 * redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option) any later version.
 *
 * The lis.to java desktop client is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with the lis.to java desktop client.
 * If not, see http://www.gnu.org/licenses/
 */

package listo.client;

import com.google.common.collect.Lists;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.annotations.XStreamAsAttribute;
import listo.client.model.Context;
import listo.client.model.Folder;
import listo.client.model.ObjectId;
import listo.client.model.operations.AddFolderOp;
import listo.client.model.operations.Operation;
import listo.utils.FileUtils2;
import listo.utils.MiscUtils;
import listo.utils.exceptions.Exceptions;
import listo.utils.logging.Log;
import listo.utils.reflection.Reflector;
import listo.utils.types.DateTime;
import org.apache.commons.lang.StringUtils;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Future;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * The ContextManager orchestrates the interaction of operations on contexts.
 * It always keeps a start context (point zero), a list of operations that was performed on this context
 * and the resulting current context.
 * <p/>
 * The ContextManager runs all core operations on its own thread.
 * All public methods provide thread-safe access to the current context.
 */
@Singleton
public class ContextManager {

    public interface ContextChangedListener {
        // CAUTION: This method is being called from the context thread.
        public void currentContextChanged(Context context, Operation undoOp, Operation redoOp);
    }

    private static class SerializationContainer {
        @XStreamAsAttribute
        public int version = 3;
        public Context context;
    }

    private final Log log;
    private final Executors executors;
    private final XStream xstream;
    private final List<ContextChangedListener> contextChangedListeners = new CopyOnWriteArrayList<ContextChangedListener>();

    private Context startContext;
    private Context currentContext; // this always is an "immutable" copy of the last current Context
    private List<Operation> operations;
    private int lastOpIx = -1; // the index of the last operation that was run to reach the current context

    @Inject
    public ContextManager(final Log log, Reflector reflector, Executors executors) {
        this.log = log;
        this.executors = executors;

        clearAll();

        // create and initialize the XStream
        xstream = new XStream();
        xstream.setMode(XStream.NO_REFERENCES);
        xstream.alias("listo-todos", SerializationContainer.class);
        xstream.registerConverter(new ObjectId.Converter());
        xstream.registerConverter(new DateTime.Converter());

        List<Class> modelClasses = reflector.f(Object.class).
                getNonAbstractImplementingClasses("listo.client.model", "listo.client.model.operations");
        modelClasses.add(SerializationContainer.class);
        for (Class modelClass : modelClasses) {
            xstream.processAnnotations(modelClass);
        }

        List<Class> opClasses = reflector.f(Operation.class)
                .getNonAbstractImplementingClasses("listo.client.model.operations");
        for (Class opClass : opClasses) {
            xstream.alias(StringUtils.removeEnd(MiscUtils.getShortClassName(opClass), "Op"), opClass);
        }
    }

    /**
     * Gets the current context of user objects.
     * The returned object is stable in that it will not change by potential operations happening
     * in parallel on the context thread.
     *
     * @return the current context
     */
    public Context getCurrentContext() {
        return currentContext;
    }

    /**
     * Forgets the current state and (re)initialized with an empty context and no ops.
     */
    public void clearAll() {
        executors.getContextExecutor().execute(new Runnable() {
            public void run() {
                log.debug("Clearing all");

                startContext = new Context();
                operations = Lists.newArrayList();
                resetToStartContext();

                notifyListeners();
            }
        });
    }

    /**
     * Adds and runs the given op.
     * First the given operation is added to the end of the operations list if we are currently at the end of the list
     * (i.e. there are no more pending ops to be (re)done). If the current state is somewhere before the
     * end of all pending operations then the list is truncated at the last op and the given op appended.
     * In any case, after the method completes the given op is the new last op in the list.
     *
     * @param op the op
     */
    public void addAndRun(final Operation op) {
        executors.getContextExecutor().execute(new Runnable() {
            public void run() {
                log.debug("Adding and running operation %s", MiscUtils.getShortClassName(op));
                removeOpenRedoOps();
                operations.add(op);
                _redoOne();
                notifyListeners();
            }
        });
    }

    /**
     * @return true if there are more operations in the list, false if this was the last one
     */
    private boolean canRedo() {
        return lastOpIx < operations.size() - 1;
    }

    /**
     * @return true if there are more operations that can be undone in the list,
     *         false if there are no more (i.e. this op was the first in the list)
     */
    private boolean canUndo() {
        return lastOpIx != -1;
    }

    /**
     * Runs the next operation in the list and notifies listeners.
     */
    public void redoOne() {
        executors.getContextExecutor().execute(new Runnable() {
            public void run() {
                _redoOne();
                notifyListeners();
            }
        });
    }

    /**
     * Restores the context before the last operation was performed and notifies listeners.
     */
    public void undoOne() {
        executors.getContextExecutor().execute(new Runnable() {
            public void run() {
                _undoOne();
                notifyListeners();
            }
        });
    }

    /**
     * Runs the next operation in the list.
     */
    private void _redoOne() {
        if (!canRedo()) {
            log.warn("No more operations to run");
            return;
        }

        Operation op = null;
        try {
            op = operations.get(lastOpIx + 1);

            // we need to run the operation not on the currentContext itself
            // but on a copy, which will afterwards become the currentContext
            // we need to do this in order to not conflict with other threads
            // that might potentially be working on the currentContext in parralel

            Context newContext = currentContext.copy();
            op.run(newContext);
            lastOpIx++;

            // the following assignment is atomical, so we do not need to protect it
            currentContext = newContext;
        }
        catch (RuntimeException e) {
            log.error("Error executing operation %1$s:\n%2$s", op, Exceptions.getTrace(e));
            throw e;
        }
    }

    /**
     * Restores the context before the last operation was performed.
     */
    private void _undoOne() {
        if (!canUndo()) {
            log.warn("No more operations to undo");
            return;
        }
        int newLastOpIx = lastOpIx - 1;
        resetToStartContext();
        while (lastOpIx != newLastOpIx) {
            _redoOne();
        }
    }

    /**
     * Resets the current context to the start context.
     */
    private void resetToStartContext() {
        currentContext = startContext.copy(); // atomic assingment, no protection required
        lastOpIx = -1;
    }

    /**
     * Prunes all operations that have not yet been redone.
     */
    private void removeOpenRedoOps() {
        while (lastOpIx < operations.size() - 1) {
            operations.remove(lastOpIx + 1);
        }
    }

    /**
     * Asyncronously creates all folders in the given path by adding and running the respective ops.
     *
     * @param pathName the pathName
     * @param iconName the icon name
     */
    public void createFolderPath(final String pathName, final String iconName) {
        executors.getContextExecutor().execute(new Runnable() {
            public void run() {
                String[] folders = StringUtils.split(pathName, '/');
                String currentFolder = null;
                for (int i = 0; i < folders.length; i++) {
                    String folder = folders[i];
                    Folder parent = StringUtils.isEmpty(currentFolder) ?
                            getCurrentContext().getRootFolder() :
                            getCurrentContext().getFolderByPathName(currentFolder);
                    if (parent == null) throw new IllegalStateException();
                    currentFolder = StringUtils.isEmpty(currentFolder) ? folder : currentFolder + '/' + folder;
                    if (getCurrentContext().getFolderByPathName(currentFolder) == null) {
                        AddFolderOp op = new AddFolderOp();
                        op.setParent(parent.getId());
                        op.addField("name", folder);
                        op.addField("icon", iconName);
                        addAndRun(op);
                        if (i < folder.length() - 1) {
                            executors.getContextExecutor().execute(this);
                        }
                        return;
                    }
                }
            }
        });
    }

    /**
     * Adds the given listener.
     *
     * @param listener the listener
     */
    public void addContextChangedListener(ContextChangedListener listener) {
        contextChangedListeners.add(listener);
    }

    /**
     * Adds the given listener.
     *
     * @param listener the listener
     */
    public void removeContextChangedListener(ContextChangedListener listener) {
        contextChangedListeners.remove(listener);
    }

    /**
     * Notifies all registered ContextChangedListeners of a change in the context.
     */
    private void notifyListeners() {
        Operation redoOp = canRedo() ? operations.get(lastOpIx + 1) : null;
        Operation undoOp = canUndo() ? operations.get(lastOpIx) : null;
        for (ContextChangedListener listener : contextChangedListeners) {
            listener.currentContextChanged(currentContext, undoOp, redoOp);
        }
    }

    /**
     * Loads the current state from the given file.
     *
     * @param filename the name of the file
     * @return true if successfully loaded
     */
    public boolean load(final String filename) {
        Future<Boolean> future = executors.getContextExecutor().submit(new Callable<Boolean>() {
            public Boolean call() throws Exception {
                File file = new File(filename);
                if (!file.exists()) {
                    log.info("Data file %s not found", filename);
                    return false;
                }
                try {
                    log.info("Loading from file %s", file.getCanonicalPath());
                    String xml = FileUtils2.readAllText(file, Charset.forName("UTF-8"));
                    xml = migrateXmlVersion(xml);
                    SerializationContainer container = (SerializationContainer) xstream.fromXML(xml);
                    startContext = container.context;
                    operations = new ArrayList<Operation>();
                    resetToStartContext();

                    notifyListeners();

                    return true;
                }
                catch (FileNotFoundException e) {
                    throw new IllegalStateException();
                }
                catch (IOException e) {
                    throw new RuntimeException(e);
                }
                catch (Exception e) {
                    log.error(Exceptions.getTrace(e));
                    throw new RuntimeException("Corrupt data file " + filename);
                }
            }
        });

        try {
            return future.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // upgrades older XML text versions to the current format
    @SuppressWarnings("fallthrough")
    private String migrateXmlVersion(String xml) {
        Matcher matcher = Pattern.compile("<listo-todos version=\"(\\d+)\">").matcher(xml);
        if (!matcher.find()) throw new RuntimeException("Unknown data file format");

        int version = Integer.valueOf(matcher.group(1));
        String logMessage = String.format("Upgrading data file XML from version %s", version);
        switch (version) {
            case 1:
                // we changed the case of the Task tag
                xml = xml.replace("<Task id=", "<task id=").replace("</Task>", "</task>");
            case 2:
                // we removed the "verb" and merged it into the "what" field (which was renamed to "desc")
                xml = xml.replace("\" what=\"", "\" desc=\"");
                matcher = Pattern.compile("verb=\"(.*)\" desc=\"(.*)\"").matcher(xml);
                StringBuilder sb = new StringBuilder();
                int offset = 0;
                while (matcher.find()) {
                    sb.append(xml.substring(offset, matcher.start()));
                    sb.append("desc=\"");
                    sb.append(matcher.group(1)).append(' ');
                    sb.append(matcher.group(2)).append('\"');
                    offset = matcher.end();
                }
                xml = sb.append(xml.substring(offset)).toString();
                break;
            default:
                logMessage = null;
        }
        if (logMessage != null) log.info(logMessage);
        return xml;
    }

    /**
     * Triggers a save operation.
     *
     * @param filename the file name of the file to save to
     */
    public void save(final String filename) {
        // we run the save operation in its own parellel thread so as to not
        // block anything else with slow disk access
        executors.getSaverExecutor().execute(new Runnable() {
            public void run() {
                log.debug("Performing context save");
                SerializationContainer container = new SerializationContainer();
                container.context = currentContext;

                File dataFile = new File(filename);
                File saveFile = dataFile;
                File tempFile = null;
                if (dataFile.exists()) {
                    tempFile = new File(dataFile + ".tmp");
                    saveFile = tempFile;
                }

                try {
                    log.debug("Saving to file %1$s", saveFile.getCanonicalPath());
                    String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + System.getProperty("line.separator") +
                            xstream.toXML(container);
                    FileUtils2.writeAllText(xml, saveFile, Charset.forName("UTF-8"));
                    if (saveFile == tempFile) {
                        log.debug("Renaming file %1$s to %1$s", saveFile.getCanonicalPath(),
                                dataFile.getCanonicalPath());
                        dataFile.delete();
                        saveFile.renameTo(dataFile);
                    }
                }
                catch (FileNotFoundException e) {
                    throw new IllegalStateException();
                }
                catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        });
    }

}
